箇条書きを折り畳むUserScript (takker)
他にも作る人がいそうなので、suffixを付けて区別しておいた
めっちゃしっかりした出来でびっくりしたMijinko_SD.icon
ありがとうございます!Mijinko_SD.icon*5
コードブロックの中まで対応しているとは思わなかったMijinko_SD.icon
よかったtakker.icon
https://gyazo.com/8feb27edf7acb8f4c274795baa7dfdf1
/icons/すごい.iconyosider.iconcFQ2f7LRuLYP.icontetsuya-k.iconkuuote.icon増井俊之.iconsta.iconkidooom.icon
息をするようにコードを書いていてすごい
https://gyazo.com/860d7971f048d8f724377abd81f28729.mp4
使い方
code:script.js
import { setup } from "./mod.js";
// 起動
const { cleanup } = setup();
// 終了したいときはcleanupを呼ぶ
// cleanup();
戻り値で後始末関数を呼ぶやり方、今回は相性悪いな
disable()/enable()みたいな関数を呼び出して何度も切り替えられるようにしたほうが便利
たたむと下線が引かれます
色は--folding-line-colorで設定してください
バグ
たまに判定がおかしくなる?
キー入力が遅くなる
別のUserScriptのせいかも
実装したいこと
すべて折り畳む
すべて展開する
page menuで有効化/無効化切り替え
青い矢印を表示せず、箇条書きのbulletをクリックしてtoggleすることはできる?yosider.icon
トップレベルのインデントとかコードブロックだとbulletが表示されてないから困るか
bulletはない場合もあるので使わなかったtakker.icon
数式行
その他UserCSSで消した
実装
大変だったtakker.icon
大体やり方わかるしすぐ実装できるだろ、とたかをくくったらこのざまだよ
CSSの調節と折りたたみ部分の計算に特に時間がかかった
お疲れさまです.iconcFQ2f7LRuLYP.icon
ありがとうございますtakker.icon
本体
code:mod.js
// @ts-check
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
/// <reference lib="./deps.d.ts" />
import { findFoldings } from "./folding.js";
/** エントリポイント
*
* @return {{ cleanup: () => void }} 後始末函数など
*/
export const setup = () => {
render();
scrapbox.addListener("layout:changed", render);
/** @type {number | undefined} */
let timer;
const callback = () => {
clearTimeout(timer);
timer = setTimeout(render, 500);
};
scrapbox.addListener("lines:changed", callback);
return {
cleanup: () => {
clearButtons();
removeCSS("folding-lines");
scrapbox.removeListener("layout:changed", render);
scrapbox.removeListener("lines:changed", callback);
},
};
};
function render() {
if (scrapbox.Layout !== "page") return;
/** 非表示にする行のID list
* @type {string[]}
*/
let hiddenIds = [];
/** @type {number | undefined} */
let animationId;
/** @type {(updater: (ids: string[]) => string[]) => void} */
const setIds = (updater) => {
hiddenIds = updater(hiddenIds);
// 再描画するときだけCSSを更新する
cancelAnimationFrame(animationId);
animationId = requestAnimationFrame(
() => {
writeCSS(
"folding-lines",
div:is(${hiddenIds.map((id) => #${id}).join(", ")}) { display: none; }, )
},
);
};
for (const { id, indent, children } of findFoldings(scrapbox.Page.lines)) {
const line = document.getElementById(L${id});
renderButton(line, indent, children.map((lineId) => L${lineId}), setIds);
}
}
/**
* @param {string} id
* @param {string} css
*/
function writeCSS(id, css) {
/** @type {HTMLStyleElement | null} */
let style = document.querySelector(head style[data-style-name="${id}"]);
if (!style) {
style = document.createElement("style");
style.dataset.styleName = id;
document.head.append(style);
}
style.textContent = css;
}
function removeCSS(id) {
document.querySelector(head style[data-style-name="${id}"])?.remove?.();
}
/** 折り畳みボタンを描画する
*
* @param {HTMLDivElement} line 描画先の行のDOM
* @param {number} indent 描画先の行のインデントの深さ
* @param {string[]} childIds 子箇条書きのID list
* @param {(updater: (ids: string[]) => string[]) => void} setIds 非表示にする行のIDを更新する函数
* @return {void}
*/
function renderButton(line, indent, childIds, setIds) {
前回のボタンを消して、新しく作り直す
子箇条書きがなければ消したままにする
流用してもいいんじゃない?takker.icon
code:mod.js
const oldButton = line.getElementsByClassName("button folding")0; let open = oldButton?.classList?.contains?.("open") ?? true;
oldButton?.remove?.();
if (childIds.length === 0) return;
ボタンを作る
大きさの調節はかなり感覚的に決めている
コードが汚くなってしまった
code:mod.js
const i = document.createElement("i");
i.className = fas fa-caret-${open ? "down" : "right"};
i.style.minWidth = "0.5em";
const button = document.createElement("a");
button.type = "button";
button.classList.add("button", "folding", "open");
button.style.position = "absolute";
const head = line.getElementsByClassName("indent")0 ?? line.getElementsByClassName("char-index")0; const offset = line.getBoundingClientRect().left;
const left = indent === 0 ? offset : head.getBoundingClientRect().left;
button.style.left = `calc(${Math.round(left - offset)}px - ${
line.getElementsByClassName("code-body").length > 0 ? 2.0 : 1.0
}em)`;
button.style.fontSize = "20px";
button.style.zIndex = "1000";
button.append(i);
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (open) {
setIds(
(hiddenIds) => {
hiddenIds.push(...childIds);
return hiddenIds;
},
);
i.className = fas fa-caret-right;
line.style.textDecoration = "solid underline var(--folding-line-color, orange) 2px";
open = false;
} else {
setIds(
(hiddenIds) =>
hiddenIds.filter(
(id) => !childIds.includes(id),
),
);
i.className = fas fa-caret-down;
line.style.textDecoration = "";
open = true;
}
});
line.insertAdjacentElement("afterbegin", button);
}
ボタンを全消しする
code:mod.js
function clearButtons() {
for (const button of Array.from(document.querySelectorAll("div.line > .button.folding"))) {
const line = button.closest("div.line");
if (line) {
line.style.textDecoration = "";
}
button.remove();
}
}
型定義ファイル
実装とは関係ない
linter errorを消すために入れたのだが、動いていなさそう……?
// @ts-checkが認識されていないっぽい?
code:deps.d.ts
declare const scrapbox: Scrapbox;
scrapbox.Page.linesを一回ループするだけで計算できるようなalgorithmにしてある
こうしないとscrapboxが固まりまくって大変なことになった
code:folding.js
/** 行を走査して、折り畳める部分を返す
*
* @param {{ text: string; id: string }[]} lines 行のリスト
* @return {Generator<{ id: string; indent: number; children: string[] }, unknown, void>}
*/
export function* findFoldings(lines) {
/** 1番目が行番号、2番目がindentの数
*
*/
const parentPos = [];
let index = -1;
for (const line of lines) {
index++;
const indent = getIndentCount(line.text);
if (parentIndent < indent) break;
yield {
indent: parentIndent,
children: lines.slice(parentIndex + 1, index).map(({ id }) => id),
};
parentPos.shift();
}
}
/** @type {string[]} */
const children = [];
// 余りは必ず階層構造になっているはず
const id = linesindex.id; children.unshift(id);
}
}
/** 行のindentを返す
*
* @param {string} text
* @return {number}
*/
const getIndentCount = (text) => text.match(/^(\s*)/)?.1?.length ?? 0; テストコード
code:folding_test.ts
import { findFoldings } from "./folding.js";
import { assertEquals } from "./deps_test.ts";
Deno.test("findFoldings()", async (t) => {
await t.step("general", () => {
const lines = [
" aa",
"a",
"a",
"",
"ccc",
" d",
" d",
" d",
" d",
" d",
" d",
" d",
" d",
"d",
" d",
"",
" d",
"",
"a",
" b",
" c",
].map((text, i) => ({ text, id: ${i} }));
{ id: "0", indent: 1, children: [] },
{ id: "1", indent: 0, children: [] },
{ id: "2", indent: 0, children: [] },
{ id: "3", indent: 0, children: [] },
{ id: "5", indent: 1, children: [] },
{ id: "8", indent: 4, children: [] },
{ id: "7", indent: 2, children: "8" }, { id: "9", indent: 2, children: [] },
{ id: "10", indent: 2, children: [] },
{ id: "12", indent: 2, children: [] },
{ id: "11", indent: 1, children: "12" }, { id: "4", indent: 0, children: "5", "6", "7", "8", "9", "10", "11", "12" }, { id: "14", indent: 2, children: [] },
{ id: "13", indent: 0, children: "14" }, { id: "16", indent: 2, children: [] },
{ id: "15", indent: 0, children: "16" }, { id: "17", indent: 0, children: [] },
{ id: "20", indent: 2, children: [] },
{ id: "19", indent: 1, children: "20" }, ]);
});
await t.step("no indent", () => {
const lines = [
"a",
"a",
"a",
].map((text, i) => ({ text, id: ${i} }));
{ id: "0", indent: 0, children: [] },
{ id: "1", indent: 0, children: [] },
{ id: "2", indent: 0, children: [] },
]);
})
});
code:deps_test.ts
UserScript.icon